Defensive Programming

The first step is to use defensive programming, i.e., to assume that mistakes will happen and to guard against them. One way to do this is to add assertions to our code so that it checks itself as it runs. An assertion is simply a statement that something must be true at a certain point in a program. When Python sees one, it checks that the assertion's condition. If it's true, Python does nothing, but if it's false, Python halts the program immediately and prints the error message provided.

  • A precondition is something that must be true at the start of a function in order for it to work correctly.
  • A postcondition is something that the function guarantees is true when it finishes.
  • An invariant is something that is always true at a particular point inside a piece of code.

For example, suppose we are representing rectangles using a tuple of four coordinates $(x_0, y_0, x_1, y_1)$. In order to do some calculations, we need to normalize the rectangle so that it is at the origin and 1.0 units long on its longest axis. This function does that, but checks that its input is correctly formatted and that its result makes sense:


In [164]:
def normalize_rectangle(rect):
    """
    Normalize a rectangle
    
    Parameter
    ---------
    rect        tuple or list of floats
                coordinates of the rectangle (x0, y0, x1, y1)
            
    Returns
    -------
    norm_rect   tuple or list of floats
                coordinates of the rectangle (0., 0., x1_n, y1_n)
    """
    assert len(rect) == 4, 'Rectangles must contain 4 coordinates'
    x0, y0, x1, y1 = rect
    assert x0 < x1, 'Invalid X coordinates'
    assert y0 < y1, 'Invalid Y coordinates'

    dx = x1 - x0
    dy = y1 - y0
    if dx > dy:
        scaled = float(dx) / dy
        upper_x, upper_y = 1.0, scaled
    else:
        scaled = float(dx) / dy
        upper_x, upper_y = scaled, 1.0

    assert 0 < upper_x <= 1.0, 'Calculated upper X coordinate invalid'
    assert 0 < upper_y <= 1.0, 'Calculated upper Y coordinate invalid'

    return (0, 0, upper_x, upper_y)

The preconditions on lines 2, 4, and 5 catch invalid inputs:


In [163]:
print normalize_rectangle( (0.0, 1.0, 2.0) ) # missing the fourth coordinate


---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
<ipython-input-163-3a97b1dcab70> in <module>()
----> 1 print normalize_rectangle( (0.0, 1.0, 2.0) ) # missing the fourth coordinate

<ipython-input-160-fdb49ef456c2> in normalize_rectangle(rect)
      1 def normalize_rectangle(rect):
----> 2     assert len(rect) == 4, 'Rectangles must contain 4 coordinates'
      3     x0, y0, x1, y1 = rect
      4     assert x0 < x1, 'Invalid X coordinates'
      5     assert y0 < y1, 'Invalid Y coordinates'

AssertionError: Rectangles must contain 4 coordinates

In [161]:
print normalize_rectangle( (0.0, 0.0, 5.0, 1.0) )


---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
<ipython-input-161-5f0ef7954aeb> in <module>()
----> 1 print normalize_rectangle( (0.0, 0.0, 5.0, 1.0) )

<ipython-input-160-fdb49ef456c2> in normalize_rectangle(rect)
     15 
     16     assert 0 < upper_x <= 1.0, 'Calculated upper X coordinate invalid'
---> 17     assert 0 < upper_y <= 1.0, 'Calculated upper Y coordinate invalid'
     18 
     19     return (0, 0, upper_x, upper_y)

AssertionError: Calculated upper Y coordinate invalid

Test Driven Development

  • write some unit tests for a function that doesn't exist yet,
  • write that function,
  • modify it until it passes all of the tests, then
  • clean up the function, i.e., make it more readable or more efficient without breaking any of the tests.

Example make a function that adds two numbers:

f(a,b)=a+b

In [153]:
#first making a test function
def test_adder(fad):
    """
    Function that tests a adder function
    
    Parameter
    ---------
    fad       func(float,float)
              function that takes two floats as input
              
    Returns
    -------
    None
    
    Raises
    ------
    AssertionError   The function passed as parameter failed the test
    
    """
    assert fad(1,1) == 2, '1+1=2'
    assert fad(2,2) == 4, '2+2=4'

In [154]:
def adder(a,b):
    return a * b

In [155]:
test_adder(adder)


---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
<ipython-input-155-ac503048d80d> in <module>()
----> 1 test_adder(adder)

<ipython-input-153-a56463eae03b> in test_adder(fad)
     18 
     19     """
---> 20     assert fad(1,1) == 2, '1+1=2'
     21     assert fad(2,2) == 4, '2+2=4'

AssertionError: 1+1=2

Using the docstring as a --simplistic-- TDD.


In [113]:
def adder2(a,b):
    """
    Function that adds two numbers
    
    Parameters
    ----------
    a       float
            
    b       float
    
    Returns
    -------
    c       float
            c = a + b
            
    Example
    -------
    >>> adder2(2, 2)
    4

    >>> adder2(2, 3)
    5
    """
    return a * b

In [124]:
### Testing the all the docstrings loaded in memory
import doctest
doctest.testmod()


**********************************************************************
File "__main__", line 21, in __main__.adder2
Failed example:
    adder2(2, 3)
Expected:
    5
Got:
    6
**********************************************************************
1 items had failures:
   1 of   2 in __main__.adder2
***Test Failed*** 1 failures.
Out[124]:
TestResults(failed=1, attempted=2)

Additional examples


In [18]:
%%file fibmodule.py
"""
Functions to compute Fibonacci sequences
"""
import numpy as np
from numpy.testing import assert_allclose

def fib(N):
    """
    Compute the first N Fibonacci numbers
    
    Parameters
    ----------
    N : integer
        The number of Fibonacci numbers to compute
    
    Returns
    -------
    x : np.ndarray
        the length-N array containing the first N
        Fibonacci numbers.
        
    Notes
    -----
    This is a pure Python implementation.  For large N,
    consider a Cython implementation
    
    Examples
    --------
    >>> fib(5)
    array([ 0.,  1.,  1.,  2.,  3.])
    """
    x = np.zeros(N, dtype=float)
    for i in range(N):
        if i == 0:
            x[i] = 0
        elif i == 1:
            x[i] = 1
        else:
            x[i] = x[i - 1] + x[i - 2]
    return x

def test_first_ten():
    nums = fib(10)
    assert_allclose(fib(10),
                    [0, 1, 1, 2, 3, 5, 8, 13, 21, 34])


Overwriting fibmodule.py

Let's try to run the test to see if it fails


In [19]:
import fibmodule
fibmodule.test_first_ten()

nosetests is a python suite to run tests automatically

The neat things is that it can also test the examples you put into your docstrings

UnitTest


In [69]:
%%writefile test_fibomodule2.py
import unittest
from fibmodule import fib
from numpy.testing import assert_allclose

class test_fibo(unittest.TestCase):
    def test_first_ten2(self):
        nums = fib(10)
        assert_allclose(fib(10),
                        [0, 1, 1, 2, 3, 5, 8, 13, 21, 34])

    def test_negative(self):
        """Testing that `fib` raises a `ValueError` with a negative number as parameter"""
        with self.assertRaises(ValueError):
            fib(-1)
        
if __name__ == '__main__':
    unittest.main()


Overwriting test_fibomodule2.py

In [70]:
%run test_fibomodule2.py


..
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

Nosetests


In [71]:
!nosetests -v fibmodule


fibmodule.test_first_ten ... ok

----------------------------------------------------------------------
Ran 1 test in 0.002s

OK

In [72]:
!nosetests -v --with-doctest fibmodule


fibmodule.test_first_ten ... ok
fib (fibmodule)
Doctest: fibmodule.fib ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.006s

OK

nosetests will run all the tests in all the files begining with "test_" in the dictionary


In [76]:
!nosetests -v


test_first_ten2 (test_fibomodule2.test_fibo) ... ok
test_negative (test_fibomodule2.test_fibo)
Testing that `fib` raises a `ValueError` with a negative number as parameter ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.088s

OK

In [78]:
!nosetests -v --with-doctest *.py


fibmodule.test_first_ten ... ok
fib (fibmodule)
Doctest: fibmodule.fib ... ok
test_first_ten2 (test_fibomodule2.test_fibo) ... ok
test_negative (test_fibomodule2.test_fibo)
Testing that `fib` raises a `ValueError` with a negative number as parameter ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.005s

OK

In [74]:


In [74]:


In [ ]: